AWS Step Functions実践:スポットインスタンス入札を自動化してみた #reinvent
こんにちは、菊池です。
AWS re:Invent 2016で発表された『AWS Step Functions』は、Lambdaで実装した処理をうまく連携させることができます。
Step Fanctions と Lambda を使ってスポットインスタンスの自動入札を試してみましたので紹介します。
やりたいこと
スポットインスタンスの入札をして起動できればOK、起動できなかった場合にはオンデマンドインスタンスを起動する。
- スポットインスタンスのリクエスト
- 入札処理を待つ
- 入札結果を確認
- 落札NG -> 4.へ
- 落札OK -> 5.へ
- オンデマンドでインスタンスを作成
- 結果をAmazon SNSへ通知
ユースケースとして、
- 日次で起動するEC2インスタンスを、スポットインスタンスで安価に起動したい。
- けど起動できないのは困るので、落札できなければオンデマンドインスタンスで起動する。
といった処理を自動化したいと思い作成してみました。
State MachineとLambda関数の作成
まずは、個々のLambda関数が正しく動作する状態を作成しておきます。作成するLambda関数は以下です。
- RequestSpotInstances
- スポットインスタンスのリクエストを実行
- GetBiddingResult
- スポットリクエストの結果を取得
- RequestOndemandInstances
- オンデマンドインスタンスのリクエストを実行
- SendNotification
- 起動したインスタンスIDをSNSへ通知
State Machine
これらを使ってStep FunctionsのState Machineを作成します。State LanguageをJSONで記述します。
- 各Stepの"Next"に次のStateを指定してつなげていく
- 実行するLambdaは、
"Resource" : "{Lambda関数のARN}"
で指定する - 各Taskで実行されるLambda関数の出力が、次のTaskの入力になる
- Choice Stateでは直前のLambdaの出力から条件の判定をする
- Step図を見ながら、意図したフローになっているか確認しながら作成しましょう
なお、一度作成したState Machineは変更できません。変更する場合には再作成が必要なので、ご注意ください。
{ "Comment" : "Launch Spot Instances or Ondemand Instances", "StartAt" : "RequestSpotInstances", "States" : { "RequestSpotInstances": { "Type" : "Task", "Resource" : "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:RequestSpotInstances", "Next" : "waitBidding" }, "waitBidding": { "Type" : "Wait", "Seconds" : 60, "Next" : "GetBiddingResult" }, "GetBiddingResult": { "Type" : "Task", "Resource" : "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:GetBiddingResult", "Next" : "CheckResult" }, "CheckResult": { "Type" : "Choice", "Choices" : [ { "Variable" : "$.LaunchedInstances", "StringEquals" : "0", "Next" : "RequestOndemandInstances" } ], "Default" : "SendNotification" }, "RequestOndemandInstances": { "Type" : "Task", "Resource" : "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:RequestOndemandInstances", "Next" : "SendNotification" }, "SendNotification": { "Type" : "Task", "Resource" : "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:SendNotification", "End" : true } } }
ステップの図は以下のようになります。
Lambda関数
全てPython(boto3)を使って記述しています。
RequestSpotInstances
#!/usr/bin/env python import boto3 # Spot request Specification spot_price = "0.01" instance_count = 2 request_type = "one-time" duration_minutes = 60 image_id = "ami-xxxxxxxx" security_groups = ["sg-xxxxxxxx"] incetance_type = "m3.medium" availability_zone = "ap-northeast-1a" subnet_id = "subnet-xxxxxxxx" client = boto3.client('ec2') def lambda_handler(event, context): # Request Spot Block response = client.request_spot_instances( SpotPrice = spot_price, InstanceCount = instance_count, Type = request_type, BlockDurationMinutes = duration_minutes, LaunchSpecification = { "ImageId" : image_id, "SecurityGroupIds" : security_groups, "InstanceType" : incetance_type, "Placement" : { "AvailabilityZone" : availability_zone }, "SubnetId" : subnet_id } ) request_ids =[] request_body = response[response.keys()[0]] for request_id in request_body: request_ids.append( request_id['SpotInstanceRequestId'] ) request = { "requestIds" : request_ids } return request
実行されると、スポットリクエストIDを以下のように返します。
{ "requestIds": [ "sir-xxxxxxxx", "sir-yyyyyyyy" ] }
GetBiddingResult
#!/usr/bin/env python import boto3 ec2 = boto3.client('ec2') def lambda_handler(event,context): request_ids = event['requestIds'] spot_response = ec2.describe_spot_instance_requests( SpotInstanceRequestIds = request_ids ) instance_ids = [] spot_request = spot_response[spot_response.keys()[0]] for resp in spot_request: if 'InstanceId' in resp: instance_ids.append( resp['InstanceId'] ) if len(instance_ids) != 0: response = { "LaunchedInstances" : instance_ids } return response else: response = { "LaunchedInstances" : "0"} return response
スポットインスタンスが落札できていれば、以下のように起動したEC2のインスタンスIDを返します。
{ "LaunchedInstances": [ "i-xxxxxxxxxxxxxxxxx", "i-yyyyyyyyyyyyyyyyy" ] }
落札できなかった場合、"0"を返します。
{ "LaunchedInstances": "0" }
RequestOndemandInstances
#!/usr/bin/env python import boto3 # Launch Specification instance_count = 2 image_id = "ami-xxxxxxxx" security_groups = ["sg-xxxxxxxx"] incetance_type = "m3.medium" availability_zone = "ap-northeast-1a" subnet_id = "subnet-xxxxxxxx" client = boto3.client('ec2') def lambda_handler(event, context): # Launch Instances run_request = client.run_instances( ImageId = image_id, MinCount = instance_count, MaxCount = instance_count, SecurityGroupIds = security_groups, InstanceType = incetance_type, Placement = { "AvailabilityZone": availability_zone }, SubnetId = subnet_id ) instance_ids =[] for instance in run_request['Instances']: instance_ids.append( instance['InstanceId'] ) response = { "LaunchedInstances" : instance_ids } return response
オンデマンドインスタンスで起動したインスタンスIDを返します。
{ "LaunchedInstances": [ "i-xxxxxxxxxxxxxxxxx", "i-yyyyyyyyyyyyyyyyy" ] }
SendNotification
#!/usr/bin/env python import boto3 def lambda_handler(event,context): instance_ids = event['LaunchedInstances'] request = { 'TopicArn' : "arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:LaunchInstancesNotification", 'Message' : str(instance_ids), 'Subject' : "LaunchInstances" } response = boto3.client('sns').publish(**request)
実行してみた
作成したState Machineを実行します。マネジメントコンソールから実行するか、CLI/SDKからStart-Execution APIを利用します。
CLIの場合:
$ aws stepfunctions start-execution --state-machine-arn "{State MachienのARN}"
実行すると一意なExecution ID(ARN)が発行され、Step図上で状態遷移が確認できます。今回の場合、waitBiddingで60秒間待っている様子も確認できます。
Execution StatusがSucceededになれば一連の処理が完了です。
スポット落札に成功した場合には以下のフローになりました!
落札が成功し、インスタンスが起動しています。
スポットインスタンスの落札に失敗すると代替フローとしてオンデマンドインスタンス起動を実行しています。
確認すると、price-too-lowで落札できていませんが、
オンデマンドでちゃんとインスタンスが起動しています。
ということで、このState Machineを時間などで実行のトリガーを出してやれば、スポットの自動入札ができます。
さいごに
いかがでしたでしょうか。
今回、初めてStep FunctionでのState Machineの作成・実行を試してみました。State Languageを初めてみたときには、またJSONか!とも思いましたが、予想以上に直感的に記述してフローを作成することができました。
これまで、複数のLambdaの連携はLambda自身で次のファンクションを呼んだり、状態を維持するためにSQSを挟んだりする必要がありましたが、そのような実装をする必要がなくなって非常に便利だと感じます。要望としては、State Machineの実行トリガーとしてCloudWatchイベントに対応してほしいと思います。現状ではスケジュール実行するためには、どこかのサーバでcronを動かしたり、Lambdaでキックする必要がありそうです。
今回は主にWaitとChoiceを使いましたが、まだまだ他の機能もあり広がりのあるサービスなので、どんどん検証していきたいと思います。